feat(matchmaking): queue guard for run start + reusable guard_queued helper#7
Merged
V-rtualized merged 3 commits intoJul 21, 2026
Conversation
ChronoFinale
marked this pull request as draft
July 13, 2026 02:35
ChronoFinale
force-pushed
the
feat/queue-blocks-run-start
branch
from
July 13, 2026 13:42
31d7133 to
ba9035a
Compare
Nothing stopped a queued player from clicking Play -> New Run (or Continue / a Challenge): the run would tear down the main menu while the queue stayed active server-side with no feedback. Wrap G.FUNCS.start_run -- the single vanilla chokepoint every run-entry flow funnels through -- and, while MPAPI.matchmaking.is_queued(), show a 'Matchmaking In Progress' dialog with three ways out: Leave Queue & Play (leaves every handle, then replays the blocked run-start back through the gate so it re-blocks if still searching), Leave Queue (return to menu), and Stay Queued (back button / Esc). No dismiss path reads search state, so it can't soft-lock if the search ends while open. Inert when not searching -- vanilla flow untouched.
handle:leave() marked the handle left and told the server, but never notified the handle's own 'left' subscribers -- so any leave initiated outside a consumer mod's own cancel path (e.g. the queue-guard overlay) left the mod's search UI stale, still showing Cancel Search. Fire the event right after the local state update, before the server round-trip, so UI resets even if the network call fails.
ChronoFinale
force-pushed
the
feat/queue-blocks-run-start
branch
from
July 13, 2026 15:31
ba9035a to
bb6f5c7
Compare
ChronoFinale
marked this pull request as ready for review
July 13, 2026 16:15
Contributor
Author
Contributor
Author
Generalize the run-start queue gate into a shared mechanism consumer mods
can reuse for their own queue-conflicting actions (e.g. lobby buttons).
- Rename run_guard.lua -> queue_guard.lua and add
MPAPI.matchmaking.guard_queued(replay): if searching, stash the blocked
call as a replay closure, show the leave-or-stay overlay, and return true
so the caller aborts; else return false. The start_run wrap now goes
through it. guard_queued documents that `replay` must re-enter the
caller's OWN complete entry point (a consumer guarding a lobby button
replays its own join/create function, not MPAPI.join_lobby -- replaying
the API primitive would join server-side but skip the consumer's
post-join setup and strand the player outside the lobby).
- Generalize the overlay's stashed action from a start_run-specific
{ e, args } (pending_run) to a replay closure (pending_action), so the
one overlay serves run-start and any consumer action. Button copy
"Leave Queue & Play" -> "Leave Queue & Continue"; description mentions
lobbies since consumers surface the same overlay for them.
dev/test_queue_guard.lua covers the guard_queued contract (blocks/stashes/
shows overlay when searching, no-ops when not) and the run-start
application (block/allow, stash-and-replay incl. re-block-while-searching),
plus a pre-fix control that strips the gate to prove it reproduces the bug.
ChronoFinale
force-pushed
the
feat/queue-blocks-run-start
branch
from
July 13, 2026 22:07
afe1130 to
3f2e1b3
Compare
This was referenced Jul 13, 2026
Merged
ChronoFinale
force-pushed
the
feat/queue-blocks-run-start
branch
from
July 13, 2026 22:18
3f2e1b3 to
794790c
Compare
ChronoFinale
force-pushed
the
feat/queue-blocks-run-start
branch
from
July 18, 2026 02:50
6dd6636 to
794790c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


A queued player could still click Play → New Run (also Continue, Challenges, and the in-run restart). The run tore down the main menu while the search stayed active server-side, with no feedback. This PR ships two things: a run-start gate applied here, and the reusable
MPAPI.matchmaking.guard_queued(replay)helper the consumer-mod PRs build on to guard lobby create/join.The bug
Nothing on the run-start path checked matchmaking state;
G.FUNCS.start_runtears down the menu (clear_queue,Game:start_run) regardless.The fix
G.FUNCS.start_runis the single vanilla funnel for New Run, Continue, Challenges, and the in-run restart. Gating there runs before menu teardown: one wrap covers every entry point, and the menu stays untouched when blocked.Returns
falsewhen not queued (caller proceeds),truewhen queued (caller aborts; the blocked call is stashed; the overlay offers Leave Queue & Continue / Leave Queue / Stay Queued).The
replayclosure must re-enter the caller's OWN complete entry point, not a lower-level primitive. ReplayingMPAPI.join_lobbydirectly would join server-side but skip the consumer's post-join setup, stranding the player. The replay re-enters a guarded entry point, so the gate is re-checked:is_queued()is false after a successful leave; if the leave did not take, it re-blocks instead of soft-locking. Only Leave Queue & Continue consumes the stash; otherwise the nextguard_queuedcall overwrites it.Behavior change:
leave()fireslefthandle:leave()now fires theleftevent locally, before the server round-trip, idempotent via the existing_leftguard. Cancel-search UI resets regardless of which path initiated the leave.Deliberately not here: lobby create/join guarding
An earlier revision guarded
MPAPI.create_lobby/join_lobbyin the API; replaying the primitive skipped the consumer's post-join setup and stranded the client. It was removed; each consumer mod guards its own entry points instead (see Related).How to review
api/matchmaking/queue_guard.lua— the core gate and thestart_runwrap.ui/queue_guard_overlay.lua— the overlay,leave_all_handles, stash consume/replay. Esc/back = Stay Queued.api/matchmaking/handle.lua— theleftevent change.localization/en-us.lua— strings only.dev/test_queue_guard.lua— tests.Tests
luajit dev/test_queue_guard.luafrom the repo root; only a LuaJIT binary needed. The script stubs the Balatro/MPAPI surface and drives the real module source. Covers theguard_queuedcontract, the run-start gate (block, stash, replay-after-leave, re-block while still searching), a pre-fix control that fails without the gate, andhandle:leftfiring exactly once.Related
guard_queued(need both API PRs): feat(lobby): block create/join while in matchmaking BalatroMultiplayer#496, feat(lobby): block create/join while in matchmaking V-rtualized/BalatroSpeedrunning#1.What it looks like — every entry point, every button
Captured by scripted visual test scenarios (maintained alongside the dev tooling, not part of this PR; each scene fakes the queued state and goes through the real wrapped
G.FUNCSpaths). Full annotated gallery: VERIFICATION.md.The guard, with the search visibly running ("Queueing m:ss", left panel):
The story from the New Run setup screen — queued, then blocked on Play. Note the guard replaces the setup overlay rather than stacking, so dismissing lands on the main menu (worth a maintainer opinion on whether that's acceptable):
Outcomes: Stay Queued (search still ticking) · Leave Queue (search gone, no run) · Leave Queue & Continue (queue left AND the blocked run really starts):